// NOTE: this file should not be imported by electron renderer because node-libcurl is not-context-aware
// Related issue https://github.com/JCMais/node-libcurl/issues/155
import { invariant } from '../../utils/invariant';
invariant(process.type !== 'renderer', 'Native abstractions for Nodejs module unavailable in renderer');
import fs from 'node:fs';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { parse as urlParse } from 'node:url';

import {
  Curl,
  CurlAuth,
  CurlCode,
  CurlFeature,
  CurlHttpVersion,
  CurlInfoDebug,
  CurlNetrc,
  CurlSslOpt,
} from '@getinsomnia/node-libcurl';
import { isValid } from 'date-fns';
import electron from 'electron';
import { v4 as uuidv4 } from 'uuid';

import { version } from '../../../package.json';
import { type AuthTypes, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED } from '../../common/constants';
import { cannotAccessPathError, describeByteSize, hasAuthHeader } from '../../common/misc';
import type { ClientCertificate } from '../../models/client-certificate';
import type { RequestHeader } from '../../models/request';
import type { ResponseHeader } from '../../models/response';
import { insecureReadFile, isPathAllowed } from '../secure-read-file';
import { buildMultipart } from './multipart';
import { parseHeaderStrings } from './parse-header-strings';
export interface CurlRequestOptions {
  requestId: string; // for cancellation
  req: RequestUsedHere;
  finalUrl: string;
  settings: SettingsUsedHere;
  certificates: ClientCertificate[];
  caCertficatePath: string | null;
  socketPath?: string;
  authHeader?: { name: string; value: string };
  // make libcurl not decompress the response content
  noDecompress?: boolean;
}
interface RequestUsedHere {
  headers: any;
  method: string;
  body: { mimeType?: string | null };
  authentication: {} | { type: AuthTypes; disabled?: boolean; username?: string; password?: string };
  settingFollowRedirects: 'global' | 'on' | 'off';
  settingRebuildPath: boolean;
  settingSendCookies: boolean;
  url: string;
  cookieJar: any;
  cookies: { name: string; value: string }[];
  suppressUserAgent: boolean;
}
interface SettingsUsedHere {
  preferredHttpVersion: string;
  maxRedirects: number;
  proxyEnabled: boolean;
  timeout: number;
  validateSSL: boolean;
  followRedirects: boolean;
  maxTimelineDataSizeKB: number;
  httpProxy: string;
  httpsProxy: string;
  noProxy: string;
  dataFolders: string[];
}

export interface ResponseTimelineEntry {
  name: keyof typeof CurlInfoDebug;
  timestamp: number;
  value: string;
}

export interface CurlRequestOutput {
  patch: ResponsePatch;
  debugTimeline: ResponseTimelineEntry[];
  headerResults: HeaderResult[];
  responseBodyPath?: string;
}

export interface ResponsePatch {
  bodyCompression?: 'zip' | null;
  bodyPath?: string;
  bytesContent?: number;
  bytesRead?: number;
  contentType?: string;
  elapsedTime: number;
  environmentId?: string | null;
  globalEnvironmentId?: string | null;
  error?: string;
  headers?: ResponseHeader[];
  httpVersion?: string;
  message?: string;
  parentId?: string;
  settingSendCookies?: boolean;
  settingStoreCookies?: boolean;
  statusCode?: number;
  statusMessage?: string;
  timelinePath?: string;
  url?: string;
}

// NOTE: this is a dictionary of functions to close open listeners
const cancelCurlRequestHandlers: Record<string, () => void> = {};
export const cancelCurlRequest = (id: string) => cancelCurlRequestHandlers[id]();
export const curlRequest = (options: CurlRequestOptions) =>
  new Promise<CurlRequestOutput>(async resolve => {
    try {
      const userdataDirectory = process.env.INSOMNIA_DATA_PATH || electron.app.getPath('userData');
      const responsesDir = path.join(userdataDirectory, 'responses');
      // TODO: remove this check, its only used for network.test.ts
      await fs.promises.mkdir(responsesDir, { recursive: true });
      const responseBodyPath = path.join(responsesDir, uuidv4() + '.response');

      const {
        requestId,
        req,
        finalUrl,
        settings,
        certificates,
        caCertficatePath,
        socketPath,
        authHeader,
        noDecompress = false,
      } = options;
      // allow reading the file as the caCert is chosen by user
      const caCert = caCertficatePath && (await insecureReadFile(caCertficatePath));

      const { curl, debugTimeline } = createConfiguredCurlInstance({
        req,
        finalUrl,
        settings,
        caCert,
        certificates,
        socketPath,
        noDecompress,
      });
      const { method, body } = req;
      // Only set CURLOPT_CUSTOMREQUEST if not HEAD or GET.
      // See https://curl.haxx.se/libcurl/c/CURLOPT_CUSTOMREQUEST.html
      // This is how you tell Curl to send a HEAD request
      if (method.toUpperCase() === 'HEAD') {
        curl.setOpt(Curl.option.NOBODY, 1);
      } else if (method.toUpperCase() === 'POST') {
        // This is how you tell Curl to send a POST request
        curl.setOpt(Curl.option.POST, 1);
      } else {
        // IMPORTANT: Only use CUSTOMREQUEST for all but HEAD and POST
        curl.setOpt(Curl.option.CUSTOMREQUEST, method);
      }

      const requestBodyPath = await parseRequestBodyPath(body);
      const requestBody = parseRequestBody({ body, method });
      const isMultipart = body.mimeType === CONTENT_TYPE_FORM_DATA && requestBodyPath;
      let requestFileDescriptor: number | undefined;
      const { authentication } = req;
      if (requestBodyPath) {
        const { isAllowed, securedPath } = isPathAllowed(requestBodyPath, settings.dataFolders);
        invariant(isAllowed, cannotAccessPathError(securedPath));

        // AWS IAM file upload not supported
        const isAWSIAM = 'type' in authentication && authentication.type === 'iam';
        invariant(!isAWSIAM, 'AWS authentication not supported for provided body type');
        const { size: contentLength } = fs.statSync(securedPath);
        curl.setOpt(Curl.option.INFILESIZE_LARGE, contentLength);
        curl.setOpt(Curl.option.UPLOAD, 1);
        // We need this, otherwise curl will send it as a POST
        curl.setOpt(Curl.option.CUSTOMREQUEST, method);
        // read file into request and close file descriptor
        requestFileDescriptor = fs.openSync(securedPath, 'r');
        curl.setOpt(Curl.option.READDATA, requestFileDescriptor);
        curl.on('end', () => closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath));
        curl.on('error', () => closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath));
      } else if (requestBody !== undefined) {
        curl.setOpt(Curl.option.POSTFIELDS, requestBody);
      }

      // NOTE: temporary workaround for testing mockbin api
      if (process.env.PLAYWRIGHT) {
        req.headers = [...req.headers, { name: 'X-Mockbin-Test', value: 'true' }];
      }

      const headerStrings = parseHeaderStrings({ req, requestBody, requestBodyPath, finalUrl, authHeader });
      curl.setOpt(Curl.option.HTTPHEADER, headerStrings);

      // Create instance and handlers, poke value options in, set up write and debug callbacks, listen for events
      const responseBodyWriteStream = fs.createWriteStream(responseBodyPath);
      // cancel request by id map
      cancelCurlRequestHandlers[requestId] = () => {
        if (requestFileDescriptor && responseBodyPath) {
          closeReadFunction(isMultipart, requestFileDescriptor, requestBodyPath);
        }
        curl.isOpen && curl.close();
      };

      // set up response writer
      let responseBodyBytes = 0;
      curl.setOpt(Curl.option.WRITEFUNCTION, buffer => {
        responseBodyBytes += buffer.length;
        responseBodyWriteStream.write(buffer);
        return buffer.length;
      });

      curl.setOpt(Curl.option.DEBUGFUNCTION, (infoType, buffer) => {
        const isSSLData = infoType === CurlInfoDebug.SslDataIn || infoType === CurlInfoDebug.SslDataOut;
        const isEmpty = buffer.length === 0;
        // Don't show cookie setting because this will display every domain in the jar
        const isAddCookie = infoType === CurlInfoDebug.Text && buffer.toString('utf8').indexOf('Added cookie') === 0;
        if (isSSLData || isEmpty || isAddCookie) {
          return 0;
        }

        // NOTE: resolves "Text" from CurlInfoDebug[CurlInfoDebug.Text]
        let name = CurlInfoDebug[infoType] as keyof typeof CurlInfoDebug;
        let timelineMessage;
        const isRequestData = infoType === CurlInfoDebug.DataOut;
        if (isRequestData) {
          // Ignore large post data messages
          const isLessThan10KB = buffer.length / 1024 < (settings.maxTimelineDataSizeKB || 1);
          timelineMessage = isLessThan10KB ? buffer.toString('utf8') : `(${describeByteSize(buffer.length)} hidden)`;
        }
        const isResponseData = infoType === CurlInfoDebug.DataIn;
        if (isResponseData) {
          timelineMessage = `Received ${describeByteSize(buffer.length)} chunk`;
          name = 'Text';
        }
        const value = timelineMessage || buffer.toString('utf8');
        debugTimeline.push({ name, value, timestamp: Date.now() });
        return 0;
      });
      // returns "rawHeaders" string in a buffer, rather than HeaderInfo[] type which is an object with deduped keys
      // this provides support for multiple set-cookies and duplicated headers
      curl.enable(CurlFeature.Raw);
      // NOTE: legacy write end callback
      curl.on('end', () => responseBodyWriteStream.end());
      curl.on('end', async (_1: any, _2: any, rawHeaders: Buffer) => {
        const patch = {
          bytesContent: responseBodyBytes,
          bytesRead: curl.getInfo(Curl.info.SIZE_DOWNLOAD) as number,
          elapsedTime: (curl.getInfo(Curl.info.TOTAL_TIME) as number) * 1000,
          url: curl.getInfo(Curl.info.EFFECTIVE_URL) as string,
        };
        curl.isOpen && curl.close();
        await waitForStreamToFinish(responseBodyWriteStream);

        const headerResults = _parseHeaders(rawHeaders);
        resolve({ patch, debugTimeline, headerResults, responseBodyPath });
      });
      // NOTE: legacy write end callback
      curl.on('error', () => responseBodyWriteStream.end());
      curl.on('error', async (err, code) => {
        const elapsedTime = (curl.getInfo(Curl.info.TOTAL_TIME) as number) * 1000;
        curl.isOpen && curl.close();
        await waitForStreamToFinish(responseBodyWriteStream);

        // If libcurl can't decompress the response, retry without decompression
        if (code === CurlCode.CURLE_BAD_CONTENT_ENCODING && !noDecompress) {
          resolve(curlRequest({ ...options, noDecompress: true }));
          return;
        }

        let error = err + '';
        let statusMessage = 'Error';

        if (code === CurlCode.CURLE_ABORTED_BY_CALLBACK) {
          error = 'Request aborted';
          statusMessage = 'Abort';
        }
        const patch = {
          statusMessage,
          error: error || 'Something went wrong inside libcurl',
          elapsedTime,
        };

        // NOTE: legacy, default headerResults
        resolve({ patch, debugTimeline, headerResults: [{ version: '', code: 0, reason: '', headers: [] }] });
      });
      curl.perform();
    } catch (error) {
      console.error(error);
      const patch = {
        statusMessage: 'Error',
        error: error.toString() || 'Something went wrong performing curl',
        elapsedTime: 0,
      };
      resolve({ patch, debugTimeline: [], headerResults: [{ version: '', code: 0, reason: '', headers: [] }] });
    }
  });

export const createConfiguredCurlInstance = ({
  req,
  finalUrl,
  settings,
  caCert,
  certificates,
  socketPath,
  noDecompress = false,
}: {
  req: RequestUsedHere;
  finalUrl: string;
  settings: SettingsUsedHere;
  certificates: ClientCertificate[];
  caCert: string | null;
  socketPath?: string;
  noDecompress?: boolean;
}) => {
  const debugTimeline: ResponseTimelineEntry[] = [];
  const curl = new Curl();
  curl.setOpt(Curl.option.URL, finalUrl);
  socketPath && curl.setOpt(Curl.option.UNIX_SOCKET_PATH, socketPath);

  // Set all the basic options

  // True so debug function works
  curl.setOpt(Curl.option.VERBOSE, true);
  // True so curl doesn't print progress
  curl.setOpt(Curl.option.NOPROGRESS, true);
  // whether to decompress response content
  curl.setOpt(Curl.option.ACCEPT_ENCODING, noDecompress ? null : '');
  // fallback to root certificates or leave unset to use keychain on macOS
  if (caCert) {
    curl.setOpt(Curl.option.CAINFO_BLOB, caCert);
  }
  // Use the system's native CA store for SSL certificate verification
  curl.setOpt(Curl.option.SSL_OPTIONS, CurlSslOpt.NativeCa);
  certificates.forEach(validCert => {
    const { passphrase, cert, key, pfx } = validCert;
    if (cert) {
      const { isAllowed, securedPath } = isPathAllowed(cert, settings.dataFolders);
      invariant(isAllowed, cannotAccessPathError(securedPath));

      curl.setOpt(Curl.option.SSLCERT, cert);
      curl.setOpt(Curl.option.SSLCERTTYPE, 'PEM');
      debugTimeline.push({ value: 'Adding SSL PEM certificate', name: 'Text', timestamp: Date.now() });
    }
    if (pfx) {
      const { isAllowed, securedPath } = isPathAllowed(pfx, settings.dataFolders);
      invariant(isAllowed, cannotAccessPathError(securedPath));

      curl.setOpt(Curl.option.SSLCERT, pfx);
      curl.setOpt(Curl.option.SSLCERTTYPE, 'P12');
      debugTimeline.push({ value: 'Adding SSL P12 certificate', name: 'Text', timestamp: Date.now() });
    }
    if (key) {
      const { isAllowed, securedPath } = isPathAllowed(key, settings.dataFolders);
      invariant(isAllowed, cannotAccessPathError(securedPath));

      curl.setOpt(Curl.option.SSLKEY, key);
      debugTimeline.push({ value: 'Adding SSL KEY certificate', name: 'Text', timestamp: Date.now() });
    }
    if (passphrase) {
      curl.setOpt(Curl.option.KEYPASSWD, passphrase);
    }
  });
  const httpVersion = getHttpVersion(settings.preferredHttpVersion);
  debugTimeline.push({ value: httpVersion.log, name: 'Text', timestamp: Date.now() });

  if (httpVersion.curlHttpVersion) {
    curl.setOpt(Curl.option.HTTP_VERSION, httpVersion.curlHttpVersion);
  }

  // Set maximum amount of redirects allowed
  // NOTE: Setting this to -1 breaks some versions of libcurl
  if (settings.maxRedirects > 0) {
    curl.setOpt(Curl.option.MAXREDIRS, settings.maxRedirects);
  }

  if (!settings.proxyEnabled) {
    curl.setOpt(Curl.option.PROXY, '');
  } else {
    const { protocol } = urlParse(req.url);
    const { httpProxy, httpsProxy, noProxy } = settings;
    const proxyHost = protocol === 'https:' ? httpsProxy : httpProxy;
    const proxy = proxyHost ? setDefaultProtocol(proxyHost) : '';
    debugTimeline.push({ value: `Using proxy: ${proxy}`, name: 'Text', timestamp: Date.now() });
    if (proxy) {
      curl.setOpt(Curl.option.PROXY, proxy);
      curl.setOpt(Curl.option.PROXYAUTH, CurlAuth.Any);
    }
    if (noProxy) {
      curl.setOpt(Curl.option.NOPROXY, noProxy);
    }
  }
  const { timeout } = settings;
  if (timeout <= 0) {
    curl.setOpt(Curl.option.TIMEOUT_MS, 0);
  } else {
    curl.setOpt(Curl.option.TIMEOUT_MS, timeout);
    debugTimeline.push({ value: `Enable timeout of ${timeout}ms`, name: 'Text', timestamp: Date.now() });
  }
  const { validateSSL } = settings;
  if (!validateSSL) {
    // Disable certificate verification
    curl.setOpt(Curl.option.SSL_VERIFYHOST, 0);
    // Disable hostname verification
    curl.setOpt(Curl.option.SSL_VERIFYPEER, 0);
  }
  debugTimeline.push({
    value: `${validateSSL ? 'Enable' : 'Disable'} SSL validation`,
    name: 'Text',
    timestamp: Date.now(),
  });

  const followRedirects =
    {
      off: false,
      on: true,
      global: settings.followRedirects,
    }[req.settingFollowRedirects] ?? true;

  curl.setOpt(Curl.option.FOLLOWLOCATION, followRedirects);

  // Don't rebuild dot sequences in path
  if (!req.settingRebuildPath) {
    curl.setOpt(Curl.option.PATH_AS_IS, true);
  }

  if (req.settingSendCookies) {
    const { cookieJar, cookies } = req;
    curl.setOpt(Curl.option.COOKIEFILE, '');

    for (const { name, value } of cookies) {
      curl.setOpt(Curl.option.COOKIE, `${name}=${value}`);
    }
    // set-cookies from previous redirects
    if (cookieJar.cookies.length) {
      debugTimeline.push({
        value: `Enable cookie sending with jar of ${cookieJar.cookies.length} cookie${cookieJar.cookies.length !== 1 ? 's' : ''}`,
        name: 'Text',
        timestamp: Date.now(),
      });
      for (const cookie of cookieJar.cookies) {
        const setCookie = [
          cookie.httpOnly ? `#HttpOnly_${cookie.domain}` : cookie.domain,
          cookie.hostOnly ? 'FALSE' : 'TRUE',
          cookie.path,
          cookie.secure ? 'TRUE' : 'FALSE',
          cookie.expires && isValid(new Date(cookie.expires))
            ? Math.round(new Date(cookie.expires).getTime() / 1000)
            : 0,
          cookie.key,
          cookie.value,
        ].join('\t');
        curl.setOpt(Curl.option.COOKIELIST, setCookie);
      }
    }
  }
  const { headers, authentication } = req;

  const userAgent: RequestHeader | null = headers.find((h: any) => h.name.toLowerCase() === 'user-agent') || null;
  const userAgentOrFallback = typeof userAgent?.value === 'string' ? userAgent?.value : 'insomnia/' + version;
  curl.setOpt(Curl.option.USERAGENT, userAgentOrFallback);
  if (req.suppressUserAgent) {
    curl.setOpt(Curl.option.USERAGENT, '');
  }
  if (authentication && 'type' in authentication) {
    const { username, password, disabled } = authentication;
    const isDigest = authentication.type === 'digest';
    const isNLTM = authentication.type === 'ntlm';
    const isDigestOrNLTM = isDigest || isNLTM;
    if (!hasAuthHeader(headers) && !disabled && isDigestOrNLTM) {
      isDigest && curl.setOpt(Curl.option.HTTPAUTH, CurlAuth.Digest);
      isNLTM && curl.setOpt(Curl.option.HTTPAUTH, CurlAuth.Ntlm);
      curl.setOpt(Curl.option.USERNAME, username || '');
      curl.setOpt(Curl.option.PASSWORD, password || '');
    }
    if (authentication.type === 'netrc') {
      curl.setOpt(Curl.option.NETRC, CurlNetrc.Required);
    }
  }
  return { curl, debugTimeline };
};

const closeReadFunction = (isMultipart: boolean, fd?: number, path?: string) => {
  if (fd) {
    fs.closeSync(fd);
  }
  // NOTE: multipart files are combined before sending, so this file is deleted after
  // alt implementation to send one part at a time https://github.com/JCMais/node-libcurl/blob/develop/examples/04-multi.js
  if (isMultipart && path) {
    fs.unlink(path, () => {});
  }
};

export interface HeaderResult {
  headers: ResponseHeader[];
  version: string;
  code: number;
  reason: string;
}
export function _parseHeaders(buffer: Buffer): HeaderResult[] {
  // split on two new lines
  const redirects = buffer.toString('utf8').split(/\r?\n\r?\n|\r\r/g);
  return redirects
    .filter(r => !!r.trim())
    .map(redirect => {
      // split on one new line
      const [first, ...rest] = redirect.split(/\r?\n|\r/g);
      const headers = rest
        .map(l => l.split(/:\s(.+)/))
        .filter(([n]) => !!n)
        .map(([name, value = '']) => ({ name, value }));

      const [version, code, ...other] = first.split(/ +/g);
      return {
        version,
        code: Number.parseInt(code, 10),
        reason: other.join(' '),
        headers,
      };
    });
}

// NOTE: legacy, suspicious, could be simplified
async function waitForStreamToFinish(stream: Readable | Writable) {
  return new Promise<void>(resolve => {
    // @ts-expect-error -- access of internal values that are intended to be private.  We should _not_ do this.
    if (stream._readableState?.finished) {
      return resolve();
    }

    // @ts-expect-error -- access of internal values that are intended to be private.  We should _not_ do this.
    if (stream._writableState?.finished) {
      return resolve();
    }

    stream.on('close', () => {
      resolve();
    });
    stream.on('error', () => {
      resolve();
    });
  });
}
const parseRequestBody = ({ body, method }: { body: any; method: string }) => {
  const isUrlEncodedForm = body.mimeType === CONTENT_TYPE_FORM_URLENCODED;
  const expectsBody = ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase());
  const hasMimetypeAndUpdateMethod = typeof body.mimeType === 'string' || expectsBody;
  if (isUrlEncodedForm) {
    const urlSearchParams = new URLSearchParams();
    (body.params || []).map((p: { name: string; value: any }) => urlSearchParams.append(p.name, p?.value || ''));
    return urlSearchParams.toString();
  }

  if (hasMimetypeAndUpdateMethod) {
    return body.text || '';
  }

  return;
};
const parseRequestBodyPath = async (body: any) => {
  const isMultipartForm = body.mimeType === CONTENT_TYPE_FORM_DATA;
  if (!isMultipartForm) {
    return body.fileName;
  }
  const { filePath } = await buildMultipart(body.params || []);
  return filePath;
};

export const getHttpVersion = (preferredHttpVersion: string) => {
  switch (preferredHttpVersion) {
    case 'V1_0': {
      return { log: 'Using HTTP 1.0', curlHttpVersion: CurlHttpVersion.V1_0 };
    }
    case 'V1_1': {
      return { log: 'Using HTTP 1.1', curlHttpVersion: CurlHttpVersion.V1_1 };
    }
    case 'V2PriorKnowledge': {
      return { log: 'Using HTTP/2 PriorKnowledge', curlHttpVersion: CurlHttpVersion.V2PriorKnowledge };
    }
    case 'V2_0': {
      return { log: 'Using HTTP/2', curlHttpVersion: CurlHttpVersion.V2_0 };
    }
    case 'v3': {
      return { log: 'Using HTTP/3', curlHttpVersion: CurlHttpVersion.v3 };
    }
    case 'default': {
      return { log: 'Using default HTTP version' };
    }
    default: {
      return { log: `Unknown HTTP version specified ${preferredHttpVersion}` };
    }
  }
};

export const setDefaultProtocol = (url: string, defaultProto?: string) => {
  const trimmedUrl = url.trim();
  defaultProto = defaultProto || 'http:';

  // If no url, don't bother returning anything
  if (!trimmedUrl) {
    return '';
  }

  // Default the proto if it doesn't exist
  if (!trimmedUrl.includes('://')) {
    return `${defaultProto}//${trimmedUrl}`;
  }

  return trimmedUrl;
};
